diff --git a/packages/plugin-charts/src/ChartContainerImpl.settleRemount.test.tsx b/packages/plugin-charts/src/ChartContainerImpl.settleRemount.test.tsx new file mode 100644 index 0000000000..38367bf54f --- /dev/null +++ b/packages/plugin-charts/src/ChartContainerImpl.settleRemount.test.tsx @@ -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>('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactElement }) => children, + }; +}); + +import { ChartContainer } from './ChartContainerImpl'; + +let mountCount = 0; +function MountProbe() { + React.useEffect(() => { + mountCount += 1; + }, []); + return
; +} + +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( + + + , + ); + }); + 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( + + + , + ); + }); + 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( + + + , + ); + }); + 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 + }); +}); diff --git a/packages/plugin-charts/src/ChartContainerImpl.tsx b/packages/plugin-charts/src/ChartContainerImpl.tsx index 2d756696c0..469c9643e8 100644 --- a/packages/plugin-charts/src/ChartContainerImpl.tsx +++ b/packages/plugin-charts/src/ChartContainerImpl.tsx @@ -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(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 | 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 (
- + {children}