From 9d3987180f961ea7498698f3416a438a26b3956e Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:57:31 +0800 Subject: [PATCH] fix(plugin-charts): draw dashboard bars on first paint via one settle re-mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard bar/area/line widgets rendered with axes + labels but NO bars on first mount, only appearing after an unrelated re-render (theme toggle / resize). Root cause is a Recharts entrance-animation race, not a container-size bug: the bar entrance is a requestAnimationFrame tween that starts at height 0 (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 is interrupted before it advances past 0, so the bars stay at height 0. Any later re-render mints a fresh Recharts animationId that re-keys the tween and lets it replay — which is why bars "appear on resize". ChartContainer now watches its own box with a ResizeObserver and, once size changes stop at a positive box (80ms debounce), bumps a `settleNonce` used as the ResponsiveContainer `key` — performing exactly one clean re-mount in a quiet window so the entrance animation runs uninterrupted. One-shot (disconnects after firing), so later resizes stay in-place. The nonce only bumps under a real layout engine, so headless (0x0) test renders are unaffected. Covers every dashboard chart path (they all funnel through ChartContainer) and full-page chart views. Adds ChartContainerImpl.settleRemount.test.tsx. Co-Authored-By: Claude Opus 4.8 --- .../ChartContainerImpl.settleRemount.test.tsx | 148 ++++++++++++++++++ .../plugin-charts/src/ChartContainerImpl.tsx | 53 ++++++- 2 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-charts/src/ChartContainerImpl.settleRemount.test.tsx 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}