Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions packages/plugin-charts/src/AdvancedChartImpl.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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, ... }
Expand DownExpand Up@@ -358,7 +363,7 @@ export default function AdvancedChartImpl({
}
});
return (
<ChartContainer config={pieConfig} className={className}>
<ChartContainer config={pieConfig} className={className} {...containerProps}>
<PieChart>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Pie
Expand DownExpand Up@@ -415,14 +420,14 @@ export default function AdvancedChartImpl({
return bv - av;
});
return (
<ChartContainer config={config} className={className}>
<ChartContainer config={config} className={className} {...containerProps}>
<FunnelChart>
<ChartTooltip content={<ChartTooltipContent />} />
<Funnel
dataKey={dataKey}
data={funnelData}
nameKey={xAxisKey}
isAnimationActive
{...animProps}
{...funnelClickProps}
>
<LabelList position="right" fill="hsl(var(--foreground))" stroke="none" dataKey={xAxisKey} />
Expand All@@ -447,8 +452,8 @@ export default function AdvancedChartImpl({
fill: resolveColor(palette[idx % palette.length]),
}));
return (
<ChartContainer config={config} className={className}>
<Treemap data={tmData} dataKey="size" nameKey="name" isAnimationActive content={<TreemapCell />} {...treemapClickProps}>
<ChartContainer config={config} className={className} {...containerProps}>
<Treemap data={tmData} dataKey="size" nameKey="name" {...animProps} content={<TreemapCell />} {...treemapClickProps}>
<Tooltip />
</Treemap>
</ChartContainer>
Expand All@@ -468,7 +473,7 @@ export default function AdvancedChartImpl({
return <div className={className} />;
}
return (
<ChartContainer config={config} className={className}>
<ChartContainer config={config} className={className} {...containerProps}>
<Sankey
data={{ nodes, links }}
nodePadding={24}
Expand All@@ -485,7 +490,7 @@ export default function AdvancedChartImpl({
// Radar chart
if (chartType === 'radar') {
return (
<ChartContainer config={config} className={className}>
<ChartContainer config={config} className={className} {...containerProps}>
<RadarChart data={data}>
<PolarGrid />
<PolarAngleAxis dataKey={xAxisKey} />
Expand All@@ -504,6 +509,7 @@ export default function AdvancedChartImpl({
stroke={color}
fill={color}
fillOpacity={0.6}
{...animProps}
/>
);
})}
Expand All@@ -515,7 +521,7 @@ export default function AdvancedChartImpl({
// Scatter chart
if (chartType === 'scatter') {
return (
<ChartContainer config={config} className={className}>
<ChartContainer config={config} className={className} {...containerProps}>
<ScatterChart>
<CartesianGrid vertical={false} />
<XAxis
Expand DownExpand Up@@ -552,6 +558,7 @@ export default function AdvancedChartImpl({
data={data}
fill={color}
fillOpacity={cmp?.fillOpacity}
{...animProps}
{...scatterClickProps}
/>
);
Expand All@@ -564,7 +571,7 @@ export default function AdvancedChartImpl({
// Combo chart (mixed bar + line on same chart)
if (chartType === 'combo') {
return (
<ChartContainer config={config} className={className}>
<ChartContainer config={config} className={className} {...containerProps}>
<BarChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey={xAxisKey} {...xAxisCommonProps} />
Expand DownExpand Up@@ -612,7 +619,7 @@ export default function AdvancedChartImpl({
const gslug = (c: string) => 'g' + c.replace(/[^a-zA-Z0-9]/g, '');

return (
<ChartContainer config={config} className={className}>
<ChartContainer config={config} className={className} {...containerProps}>
<ChartComponent data={data} layout={isHorizontal ? 'vertical' : 'horizontal'} {...cartesianClickProps}>
<defs>
{gradColors.map((c) => (
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
<ChartContainer config={{}} disableSettleRemount>
<MountProbe />
</ChartContainer>,
);
});
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
});
});
18 changes: 17 additions & 1 deletion packages/plugin-charts/src/ChartContainerImpl.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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, "")}`
Expand All@@ -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<HTMLDivElement | null>(null)
const [settleNonce, setSettleNonce] = React.useState(0)
React.useEffect(() => {
if (disableSettleRemount) return
const el = containerRef.current
if (el == null || typeof ResizeObserver === "undefined") return

Expand All@@ -106,7 +122,7 @@ function ChartContainer({
if (timer != null) clearTimeout(timer)
observer.disconnect()
}
}, [])
}, [disableSettleRemount])

return (
<ChartContext.Provider value={{ config }}>
Expand Down
4 changes: 4 additions & 0 deletions packages/plugin-dashboard/src/DashboardGridLayout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -194,6 +194,8 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
xAxisKey: xAxisKey,
series: [{ dataKey: effectiveYField }],
colors: CHART_COLORS,
// Deterministic first paint inside the grid (#2756).
isAnimationActive: false,
className: "h-full"
};
}
Expand All@@ -207,6 +209,8 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
xAxisKey: xAxisKey,
series: [{ dataKey: yField }],
colors: CHART_COLORS,
// Deterministic first paint inside the grid (#2756).
isAnimationActive: false,
className: "h-full"
};
}
Expand Down
5 changes: 5 additions & 0 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -529,6 +529,9 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
label: resolveSeriesLabel(widgetData.object, effectiveYField, effectiveAggregate?.function),
}],
colors: CHART_COLORS,
// Deterministic first paint inside the grid — no entrance
// animation to freeze at height 0 (#2756).
isAnimationActive: false,
drillDown: options.drillDown ?? defaultChartDrill(resolvedWidgetType),
compareTo: (widget as any).compareTo,
className: "h-[200px] sm:h-[250px] md:h-[300px]"
Expand All@@ -548,6 +551,8 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
label: resolveSeriesLabel(undefined, yField, undefined),
}],
colors: CHART_COLORS,
// Deterministic first paint inside the grid (#2756).
isAnimationActive: false,
className: "h-[200px] sm:h-[250px] md:h-[300px]"
};
}
Expand Down
8 changes: 7 additions & 1 deletion packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -552,7 +552,13 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
return (
<div className={cn('relative h-full w-full min-h-[220px]')}>
<SchemaRenderer
schema={{ type: 'chart', chartType, data: chartData, xAxisKey, series, ...(categoryColors ? { categoryColors } : {}) } as any}
// isAnimationActive: false — dashboard charts render at final geometry on
// the FIRST committed frame. Recharts' entrance animation is a rAF tween
// that starts at height 0 and, inside react-grid-layout's mount-time
// measurement churn, can freeze there — bars never draw until an unrelated
// re-render (#2756, follow-up to #2727's ineffective settle re-mount).
// Turning the tween off makes the first paint deterministic.
schema={{ type: 'chart', chartType, data: chartData, xAxisKey, series, isAnimationActive: false, ...(categoryColors ? { categoryColors } : {}) } as any}
onChartClick={chartDrill}
onSegmentClick={chartDrill}
/>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<Record<string, unknown>>()),
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(
<DatasetWidget
widget={{ type: 'bar', dataset: 'crm', dimensions: ['status'], values: ['count'] }}
dataSource={src}
/>,
);

// 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);
});
});
Loading