Skip to content

Commit 190cc40

Browse files
os-zhuangclaude
andauthored
fix(plugin-charts): draw dashboard chart bars on first paint via isAnimationActive=false (#2756) (#2759)
#2727's settle re-mount tried to *heal* a frozen Recharts entrance animation by re-mounting the ResponsiveContainer once the grid settled. Live (react-grid-layout) that bet doesn't hold: the re-mount can land back in mount-time measurement churn, so the rAF tween — which starts at height 0 — stays stuck there and bars never draw, though axes/labels and the data are present. Reproduced in a real browser on the showcase Chart Gallery dashboard: pre-fix, bars sit at ~0 for 8s+; identical to the reported magic-flow symptom. Fix: dashboard chart widgets render with isAnimationActive={false}, so there is no entrance-animation tween to freeze — bars render at final geometry on the first committed frame, deterministically. With the animation off there is nothing to heal, so ChartContainer skips its settle re-mount (new disableSettleRemount prop), avoiding a needless 1-frame ResponsiveContainer reflow on first paint. Every dashboard chart schema carries the flag (DatasetWidget + DashboardRenderer + DashboardGridLayout, both chart and object-chart); AdvancedChartImpl now honors isAnimationActive uniformly across bar/line/area/pie/funnel/treemap/radar/scatter (funnel/treemap/radar/ scatter previously hard-coded animation on). Standalone chart views keep their entrance animation. Verified end-to-end in a real browser (worktree console → live backend): before = frozen empty bars after 8s, after = bars drawn on first paint, zero console errors, deterministic across fresh navigations and a fresh tab. plugin-charts + plugin-dashboard: 156 tests pass; both type-check. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0ea5036 commit 190cc40

7 files changed

Lines changed: 135 additions & 12 deletions

File tree

packages/plugin-charts/src/AdvancedChartImpl.tsx

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,11 @@ export default function AdvancedChartImpl({
170170
// Only emit the prop when explicitly disabled, so the default (animated)
171171
// behavior is byte-for-byte unchanged for every existing caller.
172172
const animProps = isAnimationActive === false ? { isAnimationActive: false as const } : {};
173+
// When the entrance animation is off there is no stuck-at-0 tween to heal, so
174+
// tell ChartContainer to skip its settle re-mount — avoids a needless 1-frame
175+
// reflow on the dashboard's first paint (#2756). Animated callers keep the
176+
// heal; this object is empty for them, leaving their markup unchanged.
177+
const containerProps = isAnimationActive === false ? { disableSettleRemount: true } : {};
173178
const [isMobile, setIsMobile] = React.useState(false);
174179

175180
// Recharts' top-level onClick payload: { activeLabel, activePayload, ... }
@@ -358,7 +363,7 @@ export default function AdvancedChartImpl({
358363
}
359364
});
360365
return (
361-
<ChartContainer config={pieConfig} className={className}>
366+
<ChartContainer config={pieConfig} className={className} {...containerProps}>
362367
<PieChart>
363368
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
364369
<Pie
@@ -415,14 +420,14 @@ export default function AdvancedChartImpl({
415420
return bv - av;
416421
});
417422
return (
418-
<ChartContainer config={config} className={className}>
423+
<ChartContainer config={config} className={className} {...containerProps}>
419424
<FunnelChart>
420425
<ChartTooltip content={<ChartTooltipContent />} />
421426
<Funnel
422427
dataKey={dataKey}
423428
data={funnelData}
424429
nameKey={xAxisKey}
425-
isAnimationActive
430+
{...animProps}
426431
{...funnelClickProps}
427432
>
428433
<LabelList position="right" fill="hsl(var(--foreground))" stroke="none" dataKey={xAxisKey} />
@@ -447,8 +452,8 @@ export default function AdvancedChartImpl({
447452
fill: resolveColor(palette[idx % palette.length]),
448453
}));
449454
return (
450-
<ChartContainer config={config} className={className}>
451-
<Treemap data={tmData} dataKey="size" nameKey="name" isAnimationActive content={<TreemapCell />} {...treemapClickProps}>
455+
<ChartContainer config={config} className={className} {...containerProps}>
456+
<Treemap data={tmData} dataKey="size" nameKey="name" {...animProps} content={<TreemapCell />} {...treemapClickProps}>
452457
<Tooltip />
453458
</Treemap>
454459
</ChartContainer>
@@ -468,7 +473,7 @@ export default function AdvancedChartImpl({
468473
return <div className={className} />;
469474
}
470475
return (
471-
<ChartContainer config={config} className={className}>
476+
<ChartContainer config={config} className={className} {...containerProps}>
472477
<Sankey
473478
data={{ nodes, links }}
474479
nodePadding={24}
@@ -485,7 +490,7 @@ export default function AdvancedChartImpl({
485490
// Radar chart
486491
if (chartType === 'radar') {
487492
return (
488-
<ChartContainer config={config} className={className}>
493+
<ChartContainer config={config} className={className} {...containerProps}>
489494
<RadarChart data={data}>
490495
<PolarGrid />
491496
<PolarAngleAxis dataKey={xAxisKey} />
@@ -504,6 +509,7 @@ export default function AdvancedChartImpl({
504509
stroke={color}
505510
fill={color}
506511
fillOpacity={0.6}
512+
{...animProps}
507513
/>
508514
);
509515
})}
@@ -515,7 +521,7 @@ export default function AdvancedChartImpl({
515521
// Scatter chart
516522
if (chartType === 'scatter') {
517523
return (
518-
<ChartContainer config={config} className={className}>
524+
<ChartContainer config={config} className={className} {...containerProps}>
519525
<ScatterChart>
520526
<CartesianGrid vertical={false} />
521527
<XAxis
@@ -552,6 +558,7 @@ export default function AdvancedChartImpl({
552558
data={data}
553559
fill={color}
554560
fillOpacity={cmp?.fillOpacity}
561+
{...animProps}
555562
{...scatterClickProps}
556563
/>
557564
);
@@ -564,7 +571,7 @@ export default function AdvancedChartImpl({
564571
// Combo chart (mixed bar + line on same chart)
565572
if (chartType === 'combo') {
566573
return (
567-
<ChartContainer config={config} className={className}>
574+
<ChartContainer config={config} className={className} {...containerProps}>
568575
<BarChart data={data}>
569576
<CartesianGrid vertical={false} />
570577
<XAxis dataKey={xAxisKey} {...xAxisCommonProps} />
@@ -612,7 +619,7 @@ export default function AdvancedChartImpl({
612619
const gslug = (c: string) => 'g' + c.replace(/[^a-zA-Z0-9]/g, '');
613620

614621
return (
615-
<ChartContainer config={config} className={className}>
622+
<ChartContainer config={config} className={className} {...containerProps}>
616623
<ChartComponent data={data} layout={isHorizontal ? 'vertical' : 'horizontal'} {...cartesianClickProps}>
617624
<defs>
618625
{gradColors.map((c) => (

packages/plugin-charts/src/ChartContainerImpl.settleRemount.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,29 @@ describe('ChartContainer — settle re-mount (dashboard-chart-empty-first-render
145145

146146
expect(mountCount).toBe(1); // no re-mount, no loop
147147
});
148+
149+
// #2756: dashboard charts render with `isAnimationActive={false}`, so there is
150+
// no entrance-animation tween to heal. `disableSettleRemount` must fully
151+
// suppress the settle re-mount — even at a positive, stable box — so the first
152+
// paint is never followed by a needless ResponsiveContainer reflow.
153+
it('never re-mounts when disableSettleRemount is set, even after settling at a non-zero box', () => {
154+
act(() => {
155+
render(
156+
<ChartContainer config={{}} disableSettleRemount>
157+
<MountProbe />
158+
</ChartContainer>,
159+
);
160+
});
161+
expect(mountCount).toBe(1);
162+
163+
// A real, settled positive box would normally trigger exactly one re-mount…
164+
fireResize(320, 240);
165+
act(() => {
166+
vi.advanceTimersByTime(500);
167+
});
168+
169+
// …but the flag opts out of it entirely: the observer never even armed.
170+
expect(mountCount).toBe(1);
171+
expect(roCallback).toBeNull(); // no ResizeObserver was created
172+
});
148173
});

packages/plugin-charts/src/ChartContainerImpl.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,20 @@ function ChartContainer({
4848
className,
4949
children,
5050
config,
51+
disableSettleRemount,
5152
...props
5253
}: React.ComponentProps<"div"> & {
5354
config: ChartConfig
5455
children: React.ComponentProps<
5556
typeof ResponsiveContainer
5657
>["children"]
58+
/**
59+
* Skip the settle re-mount below. Set by callers that render their series with
60+
* `isAnimationActive={false}` (e.g. dashboard charts, see #2756): with no
61+
* entrance-animation tween there is nothing to "heal", so re-mounting would
62+
* only cost a needless 1-frame ResponsiveContainer reflow on first paint.
63+
*/
64+
disableSettleRemount?: boolean
5765
}) {
5866
const uniqueId = React.useId()
5967
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
@@ -78,9 +86,17 @@ function ChartContainer({
7886
// box). Headless/jsdom/happy-dom renders report a 0×0 box, so `settleNonce`
7987
// stays 0 and those tests see a single, ordinary render. See
8088
// dashboard-chart-empty-first-render.
89+
//
90+
// NOTE (#2756): the settle re-mount only *heals* an interrupted entrance
91+
// animation — it bets that a clean re-mount replays the tween to completion.
92+
// In a live react-grid-layout dashboard that bet doesn't hold (the re-mount
93+
// itself can land back in the grid/measurement churn), so dashboard charts
94+
// instead render with `isAnimationActive={false}` and pass
95+
// `disableSettleRemount` — there is no tween to heal and no reflow to pay for.
8196
const containerRef = React.useRef<HTMLDivElement | null>(null)
8297
const [settleNonce, setSettleNonce] = React.useState(0)
8398
React.useEffect(() => {
99+
if (disableSettleRemount) return
84100
const el = containerRef.current
85101
if (el == null || typeof ResizeObserver === "undefined") return
86102

@@ -106,7 +122,7 @@ function ChartContainer({
106122
if (timer != null) clearTimeout(timer)
107123
observer.disconnect()
108124
}
109-
}, [])
125+
}, [disableSettleRemount])
110126

111127
return (
112128
<ChartContext.Provider value={{ config }}>

packages/plugin-dashboard/src/DashboardGridLayout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
194194
xAxisKey: xAxisKey,
195195
series: [{ dataKey: effectiveYField }],
196196
colors: CHART_COLORS,
197+
// Deterministic first paint inside the grid (#2756).
198+
isAnimationActive: false,
197199
className: "h-full"
198200
};
199201
}
@@ -207,6 +209,8 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
207209
xAxisKey: xAxisKey,
208210
series: [{ dataKey: yField }],
209211
colors: CHART_COLORS,
212+
// Deterministic first paint inside the grid (#2756).
213+
isAnimationActive: false,
210214
className: "h-full"
211215
};
212216
}

packages/plugin-dashboard/src/DashboardRenderer.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,9 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
529529
label: resolveSeriesLabel(widgetData.object, effectiveYField, effectiveAggregate?.function),
530530
}],
531531
colors: CHART_COLORS,
532+
// Deterministic first paint inside the grid — no entrance
533+
// animation to freeze at height 0 (#2756).
534+
isAnimationActive: false,
532535
drillDown: options.drillDown ?? defaultChartDrill(resolvedWidgetType),
533536
compareTo: (widget as any).compareTo,
534537
className: "h-[200px] sm:h-[250px] md:h-[300px]"
@@ -548,6 +551,8 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
548551
label: resolveSeriesLabel(undefined, yField, undefined),
549552
}],
550553
colors: CHART_COLORS,
554+
// Deterministic first paint inside the grid (#2756).
555+
isAnimationActive: false,
551556
className: "h-[200px] sm:h-[250px] md:h-[300px]"
552557
};
553558
}

packages/plugin-dashboard/src/DatasetWidget.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,13 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
552552
return (
553553
<div className={cn('relative h-full w-full min-h-[220px]')}>
554554
<SchemaRenderer
555-
schema={{ type: 'chart', chartType, data: chartData, xAxisKey, series, ...(categoryColors ? { categoryColors } : {}) } as any}
555+
// isAnimationActive: false — dashboard charts render at final geometry on
556+
// the FIRST committed frame. Recharts' entrance animation is a rAF tween
557+
// that starts at height 0 and, inside react-grid-layout's mount-time
558+
// measurement churn, can freeze there — bars never draw until an unrelated
559+
// re-render (#2756, follow-up to #2727's ineffective settle re-mount).
560+
// Turning the tween off makes the first paint deterministic.
561+
schema={{ type: 'chart', chartType, data: chartData, xAxisKey, series, isAnimationActive: false, ...(categoryColors ? { categoryColors } : {}) } as any}
556562
onChartClick={chartDrill}
557563
onSegmentClick={chartDrill}
558564
/>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #2756 — dashboard charts must render at final geometry on the FIRST committed
5+
* frame. Recharts' entrance animation is a requestAnimationFrame tween that
6+
* starts at height 0 and, inside react-grid-layout's mount-time measurement
7+
* churn, can freeze there — the axes/labels paint but the bars never draw until
8+
* an unrelated re-render. #2727's settle re-mount tried to heal that live and
9+
* didn't. The deterministic fix: dashboard chart widgets pass
10+
* `isAnimationActive: false`, so there is no tween to freeze.
11+
*
12+
* This asserts the wiring at the source — the chart schema DatasetWidget hands
13+
* to the renderer carries the flag — captured via a stubbed SchemaRenderer.
14+
*/
15+
16+
import { describe, it, expect, vi, afterEach } from 'vitest';
17+
import { render, cleanup, waitFor } from '@testing-library/react';
18+
19+
let lastChartSchema: any = null;
20+
21+
vi.mock('@object-ui/react', async (importOriginal) => ({
22+
...(await importOriginal<Record<string, unknown>>()),
23+
SchemaRenderer: (props: any) => {
24+
lastChartSchema = props.schema;
25+
return null;
26+
},
27+
}));
28+
29+
import { DatasetWidget } from '../DatasetWidget';
30+
31+
afterEach(() => {
32+
cleanup();
33+
lastChartSchema = null;
34+
});
35+
36+
describe('DatasetWidget — dashboard chart animation (#2756)', () => {
37+
it('hands the chart renderer isAnimationActive: false so bars draw on first paint', async () => {
38+
const src = { queryDataset: vi.fn(async () => ({
39+
rows: [
40+
{ status: '合作中', count: 5 },
41+
{ status: '已流失', count: 3 },
42+
{ status: '潜在', count: 4 },
43+
],
44+
})) };
45+
46+
render(
47+
<DatasetWidget
48+
widget={{ type: 'bar', dataset: 'crm', dimensions: ['status'], values: ['count'] }}
49+
dataSource={src}
50+
/>,
51+
);
52+
53+
// Once data resolves the chart branch renders through SchemaRenderer.
54+
await waitFor(() => expect(lastChartSchema).not.toBeNull());
55+
expect(lastChartSchema.type).toBe('chart');
56+
expect(lastChartSchema.chartType).toBe('bar');
57+
// The fix: the entrance-animation tween is turned off.
58+
expect(lastChartSchema.isAnimationActive).toBe(false);
59+
});
60+
});

0 commit comments

Comments
 (0)