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
148 changes: 148 additions & 0 deletions packages/plugin-charts/src/ChartContainerImpl.settleRemount.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Regression: dashboard bar charts render EMPTY on first mount and only draw
* bars after an unrelated re-render (theme toggle / resize).
*
* Root cause: Recharts' entrance animation is a requestAnimationFrame tween that
* starts at height 0; when the chart mounts while its react-grid-layout box is
* still settling, that tween is interrupted before it advances and the bars stay
* stuck at 0. `ChartContainer` fixes this by re-mounting the chart ONCE — via a
* `settleNonce` keyed on the ResponsiveContainer — after the ResizeObserver
* reports that size changes have stopped at a positive box, so the entrance
* animation replays in a quiet window.
*
* These tests drive a controllable ResizeObserver to prove:
* 1. a settled, non-zero box triggers exactly one clean re-mount, and
* 2. a 0×0 (headless) box never re-mounts — so real DOM-less tests are
* unaffected and there is no re-mount loop.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, cleanup, act } from '@testing-library/react';

// A ResizeObserver we can trigger by hand. Only one is created per ChartContainer.
let roCallback: ResizeObserverCallback | null = null;
class ControllableResizeObserver {
constructor(cb: ResizeObserverCallback) {
roCallback = cb;
}
observe() {}
unobserve() {}
disconnect() {}
}

const fireResize = (width: number, height: number) => {
act(() => {
roCallback?.(
[{ contentRect: { width, height } } as ResizeObserverEntry],
null as unknown as ResizeObserver,
);
});
};

// ResponsiveContainer measures 0×0 under happy-dom, so replace it with a
// passthrough. Keying it (as ChartContainer does) re-mounts this subtree — which
// is exactly what we assert via the child's mount counter.
vi.mock('recharts', async () => {
const actual = await vi.importActual<Record<string, unknown>>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: { children: React.ReactElement }) => children,
};
});

import { ChartContainer } from './ChartContainerImpl';

let mountCount = 0;
function MountProbe() {
React.useEffect(() => {
mountCount += 1;
}, []);
return <div data-testid="probe" />;
}

let originalRO: typeof ResizeObserver | undefined;

beforeEach(() => {
vi.useFakeTimers();
roCallback = null;
mountCount = 0;
originalRO = globalThis.ResizeObserver;
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
ControllableResizeObserver;
});

afterEach(() => {
cleanup();
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = originalRO;
vi.useRealTimers();
});

describe('ChartContainer — settle re-mount (dashboard-chart-empty-first-render)', () => {
it('re-mounts the chart once after the container settles at a non-zero size', () => {
act(() => {
render(
<ChartContainer config={{}}>
<MountProbe />
</ChartContainer>,
);
});
expect(mountCount).toBe(1); // initial mount (settleNonce = 0)

// The grid settles: a positive box, then no further changes.
fireResize(320, 240);
act(() => {
vi.advanceTimersByTime(100); // debounce window elapses
});

// Exactly one clean re-mount → the Recharts entrance animation replays.
expect(mountCount).toBe(2);
});

it('debounces mid-settle resizes into a single re-mount', () => {
act(() => {
render(
<ChartContainer config={{}}>
<MountProbe />
</ChartContainer>,
);
});
expect(mountCount).toBe(1);

// Several resizes arrive faster than the debounce while the grid settles.
fireResize(100, 200);
act(() => vi.advanceTimersByTime(40));
fireResize(280, 220);
act(() => vi.advanceTimersByTime(40));
fireResize(320, 240);
act(() => vi.advanceTimersByTime(100)); // now let it settle

expect(mountCount).toBe(2); // still only one re-mount, not one per resize
});

it('never re-mounts under a 0×0 (headless) layout', () => {
act(() => {
render(
<ChartContainer config={{}}>
<MountProbe />
</ChartContainer>,
);
});
expect(mountCount).toBe(1);

fireResize(0, 0); // happy-dom / jsdom report no layout
act(() => {
vi.advanceTimersByTime(500);
});

expect(mountCount).toBe(1); // no re-mount, no loop
});
});
53 changes: 52 additions & 1 deletion packages/plugin-charts/src/ChartContainerImpl.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,9 +58,60 @@ function ChartContainer({
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`

// Re-mount the chart exactly ONCE, after its container has settled at a real,
// non-zero size. Why: a Recharts bar/area/line entrance animation is a
// requestAnimationFrame tween that starts at height 0 (see recharts'
// JavascriptAnimate: `useState(isActive ? 0 : 1)`). Inside a react-grid-layout
// dashboard the widget's box settles over several frames right after mount,
// and the tween kicked off during that churn can be interrupted before it ever
// advances past 0 — so the chart paints its axes/labels but the bars stay stuck
// at height 0. Any *unrelated* later re-render (a theme toggle, a manual
// resize) mints a fresh Recharts `animationId`, which re-keys the tween and
// lets it replay to completion — which is why the bars "appear on resize". We
// reproduce that single healing re-render automatically: once the ResizeObserver
// reports that size changes have stopped at a positive box, we bump
// `settleNonce`, which re-keys the ResponsiveContainer below so the whole chart
// performs one clean re-mount in a quiet window. The entrance animation then
// runs uninterrupted and the bars actually draw on first paint.
//
// The nonce only ever bumps under a genuine layout engine (a positive, stable
// 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.
const containerRef = React.useRef<HTMLDivElement | null>(null)
const [settleNonce, setSettleNonce] = React.useState(0)
React.useEffect(() => {
const el = containerRef.current
if (el == null || typeof ResizeObserver === "undefined") return

let settled = false
let timer: ReturnType<typeof setTimeout> | undefined
const observer = new ResizeObserver((entries) => {
if (settled) return
const box = entries[0]?.contentRect
if (box == null || box.width <= 0 || box.height <= 0) return
// Debounce: only re-mount once size changes have STOPPED, so we replay the
// entrance animation after the grid finishes settling — not midway through
// it (which would just re-arm the same race).
if (timer != null) clearTimeout(timer)
timer = setTimeout(() => {
settled = true
observer.disconnect()
setSettleNonce((n) => n + 1)
}, 80)
})
observer.observe(el)
return () => {
settled = true
if (timer != null) clearTimeout(timer)
observer.disconnect()
}
}, [])

return (
<ChartContext.Provider value={{ config }}>
<div
ref={containerRef}
data-slot="chart"
data-chart={chartId}
className={cn(
Expand All@@ -81,7 +132,7 @@ function ChartContainer({
{...props}
>
<ChartStyle id={chartId} config={config} />
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer key={settleNonce} width="100%" height="100%">
{children}
</ResponsiveContainer>
</div>
Expand Down
Loading