diff --git a/.changeset/giant-tips-laugh.md b/.changeset/giant-tips-laugh.md new file mode 100644 index 000000000..a0da0df52 --- /dev/null +++ b/.changeset/giant-tips-laugh.md @@ -0,0 +1,5 @@ +--- +"@lightsparkdev/origin": patch +--- + +- Add initialWidth prop to Origin charts for SSR support diff --git a/packages/origin/src/components/Chart/BarChart.tsx b/packages/origin/src/components/Chart/BarChart.tsx index ab48c2c38..28f15b993 100644 --- a/packages/origin/src/components/Chart/BarChart.tsx +++ b/packages/origin/src/components/Chart/BarChart.tsx @@ -41,6 +41,11 @@ const clickIndexMeta = (index: number) => ({ index }); export interface BarChartProps extends React.ComponentPropsWithoutRef<"div"> { data: ChartDatum[]; + /** + * Pre-measurement width in pixels. Used as a fallback before + * ResizeObserver fires, enabling server-side rendering. + */ + initialWidth?: number; dataKey?: string; series?: Series[]; xKey?: string; @@ -116,12 +121,13 @@ export const Bar = React.forwardRef(function Bar( animate = true, getBarColor, orientation = "vertical", + initialWidth, className, ...props }, ref, ) { - const { width, attachRef } = useResizeWidth(); + const { width, attachRef } = useResizeWidth(initialWidth); const tooltipRef = React.useRef(null); const [activeIndex, setActiveIndex] = React.useState(null); diff --git a/packages/origin/src/components/Chart/Chart.stories.tsx b/packages/origin/src/components/Chart/Chart.stories.tsx index 7e4eecfa0..a702886e7 100644 --- a/packages/origin/src/components/Chart/Chart.stories.tsx +++ b/packages/origin/src/components/Chart/Chart.stories.tsx @@ -44,6 +44,7 @@ export const Line: Story = { fill: false, fadeLeft: false, compareLabel: "", + initialWidth: undefined, }, argTypes: { curve: { control: "radio", options: ["monotone", "linear"] }, @@ -228,6 +229,7 @@ export const BarGrouped: Story = { legend: false, loading: false, stacked: false, + initialWidth: undefined, }, argTypes: { orientation: { control: "radio", options: ["vertical", "horizontal"] }, diff --git a/packages/origin/src/components/Chart/Chart.unit.test.ts b/packages/origin/src/components/Chart/Chart.unit.test.ts index 8ee75d01c..24d768687 100644 --- a/packages/origin/src/components/Chart/Chart.unit.test.ts +++ b/packages/origin/src/components/Chart/Chart.unit.test.ts @@ -7,6 +7,8 @@ */ import { describe, it, expect, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useResizeWidth } from "./hooks"; import { linearScale, niceTicks, @@ -871,3 +873,66 @@ describe("sankeyLinkPath", () => { expect(path).toContain("C"); }); }); + +// --------------------------------------------------------------------------- +// useResizeWidth +// --------------------------------------------------------------------------- + +describe("useResizeWidth", () => { + it("returns 0 when called with no arguments", () => { + const { result } = renderHook(() => useResizeWidth()); + expect(result.current.width).toBe(0); + expect(typeof result.current.attachRef).toBe("function"); + }); + + it("returns initialWidth when no observer has fired", () => { + const { result } = renderHook(() => useResizeWidth(800)); + expect(result.current.width).toBe(800); + }); + + it("returns initialWidth for various values", () => { + const { result: r1 } = renderHook(() => useResizeWidth(1024)); + expect(r1.current.width).toBe(1024); + + const { result: r2 } = renderHook(() => useResizeWidth(320)); + expect(r2.current.width).toBe(320); + }); + + it("returns 0 when initialWidth is 0", () => { + const { result } = renderHook(() => useResizeWidth(0)); + expect(result.current.width).toBe(0); + }); + + it("observer measurement takes over from initialWidth", () => { + let observerCallback: ResizeObserverCallback; + const mockObserver = { + observe: vi.fn(), + disconnect: vi.fn(), + unobserve: vi.fn(), + }; + vi.stubGlobal( + "ResizeObserver", + vi.fn((cb: ResizeObserverCallback) => { + observerCallback = cb; + return mockObserver; + }), + ); + + const { result } = renderHook(() => useResizeWidth(800)); + expect(result.current.width).toBe(800); + + const fakeNode = { clientWidth: 0 } as HTMLDivElement; + act(() => result.current.attachRef(fakeNode)); + + act(() => { + observerCallback( + [{ contentRect: { width: 600 } }] as ResizeObserverEntry[], + {} as ResizeObserver, + ); + }); + + expect(result.current.width).toBe(600); + + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/origin/src/components/Chart/ComposedChart.tsx b/packages/origin/src/components/Chart/ComposedChart.tsx index cea22ce25..f8805ef5d 100644 --- a/packages/origin/src/components/Chart/ComposedChart.tsx +++ b/packages/origin/src/components/Chart/ComposedChart.tsx @@ -59,6 +59,11 @@ type ResolvedComposedSeries = { export interface ComposedChartProps extends React.ComponentPropsWithoutRef<"div"> { data: ChartDatum[]; + /** + * Pre-measurement width in pixels. Used as a fallback before + * ResizeObserver fires, enabling server-side rendering. + */ + initialWidth?: number; series: ComposedSeries[]; xKey?: string; height?: number; @@ -130,12 +135,13 @@ export const Composed = React.forwardRef( connectNulls = true, yDomain: yDomainProp, yDomainRight: yDomainRightProp, + initialWidth, className, ...props }, ref, ) { - const { width, attachRef } = useResizeWidth(); + const { width, attachRef } = useResizeWidth(initialWidth); const trackedClick = useTrackedCallback( analyticsName, "Chart.Composed", diff --git a/packages/origin/src/components/Chart/FunnelChart.tsx b/packages/origin/src/components/Chart/FunnelChart.tsx index ed2ced3ba..f3940fde5 100644 --- a/packages/origin/src/components/Chart/FunnelChart.tsx +++ b/packages/origin/src/components/Chart/FunnelChart.tsx @@ -19,6 +19,11 @@ export interface FunnelStage { export interface FunnelChartProps extends React.ComponentPropsWithoutRef<"div"> { data: FunnelStage[]; + /** + * Pre-measurement width in pixels. Used as a fallback before + * ResizeObserver fires, enabling server-side rendering. + */ + initialWidth?: number; formatValue?: (value: number) => string; formatRate?: (rate: number) => string; showRates?: boolean; @@ -61,12 +66,13 @@ export const Funnel = React.forwardRef( onClickDatum, onActiveChange, analyticsName, + initialWidth, className, ...props }, ref, ) { - const { width, attachRef } = useResizeWidth(); + const { width, attachRef } = useResizeWidth(initialWidth); const [activeIndex, setActiveIndex] = React.useState(null); const onActiveChangeRef = React.useRef(onActiveChange); diff --git a/packages/origin/src/components/Chart/LineChart.tsx b/packages/origin/src/components/Chart/LineChart.tsx index 8351ba924..6902d5c50 100644 --- a/packages/origin/src/components/Chart/LineChart.tsx +++ b/packages/origin/src/components/Chart/LineChart.tsx @@ -47,6 +47,11 @@ export interface LineChartProps extends React.ComponentPropsWithoutRef<"div"> { * Array of data objects. Each object should contain keys matching `dataKey` or `series[].key`. */ data: ChartDatum[]; + /** + * Pre-measurement width in pixels. Used as a fallback before + * ResizeObserver fires, enabling server-side rendering. + */ + initialWidth?: number; /** Data key for single-series charts. Pass this OR `series`, not both. */ dataKey?: string; /** Series configuration for multi-series charts. */ @@ -150,12 +155,13 @@ export const Line = React.forwardRef( formatXLabel, formatYLabel, connectNulls = true, + initialWidth, className, ...props }, ref, ) { - const { width, attachRef } = useResizeWidth(); + const { width, attachRef } = useResizeWidth(initialWidth); const trackedClick = useTrackedCallback( analyticsName, "Chart.Line", diff --git a/packages/origin/src/components/Chart/PieChart.tsx b/packages/origin/src/components/Chart/PieChart.tsx index 2edbdf984..48eb91762 100644 --- a/packages/origin/src/components/Chart/PieChart.tsx +++ b/packages/origin/src/components/Chart/PieChart.tsx @@ -17,6 +17,11 @@ export interface PieSegment { export interface PieChartProps extends React.ComponentPropsWithoutRef<"div"> { data: PieSegment[]; + /** + * Pre-measurement width in pixels. Used as a fallback before + * ResizeObserver fires, enabling server-side rendering. + */ + initialWidth?: number; height?: number; /** Inner radius ratio (0-1). Defaults to 0.65. */ innerRadius?: number; @@ -107,6 +112,7 @@ export const Pie = React.forwardRef(function Pie( analyticsName, ariaLabel, formatValue, + initialWidth, className, ...props }, @@ -123,7 +129,7 @@ export const Pie = React.forwardRef(function Pie( onClickDatum ? clickIndexMeta : undefined, ); - const { width, attachRef } = useResizeWidth(); + const { width, attachRef } = useResizeWidth(initialWidth); const [activeIndex, setActiveIndex] = React.useState(null); const mergedRef = useMergedRef(ref, attachRef); diff --git a/packages/origin/src/components/Chart/SankeyChart.tsx b/packages/origin/src/components/Chart/SankeyChart.tsx index 90e500851..118886c77 100644 --- a/packages/origin/src/components/Chart/SankeyChart.tsx +++ b/packages/origin/src/components/Chart/SankeyChart.tsx @@ -23,6 +23,11 @@ export type { SankeyNode, SankeyLink } from "./sankeyLayout"; export interface SankeyChartProps extends React.ComponentPropsWithoutRef<"div"> { data: SankeyData; + /** + * Pre-measurement width in pixels. Used as a fallback before + * ResizeObserver fires, enabling server-side rendering. + */ + initialWidth?: number; nodeWidth?: number; nodePadding?: number; height?: number; @@ -78,6 +83,7 @@ export const Sankey = React.forwardRef( onClickNode, onClickLink, analyticsName, + initialWidth, className, ...props }, @@ -98,7 +104,7 @@ export const Sankey = React.forwardRef( onClickLink ? sankeyLinkClickMeta : undefined, ); - const { width, attachRef } = useResizeWidth(); + const { width, attachRef } = useResizeWidth(initialWidth); const [active, setActive] = React.useState(null); const tooltipRef = React.useRef(null); const rootRef = React.useRef(null); diff --git a/packages/origin/src/components/Chart/ScatterChart.tsx b/packages/origin/src/components/Chart/ScatterChart.tsx index 29216dd55..97c2b5a18 100644 --- a/packages/origin/src/components/Chart/ScatterChart.tsx +++ b/packages/origin/src/components/Chart/ScatterChart.tsx @@ -39,6 +39,11 @@ export interface ScatterSeries { export interface ScatterChartProps extends React.ComponentPropsWithoutRef<"div"> { data: ScatterSeries[]; + /** + * Pre-measurement width in pixels. Used as a fallback before + * ResizeObserver fires, enabling server-side rendering. + */ + initialWidth?: number; height?: number; grid?: boolean; tooltip?: TooltipProp; @@ -113,6 +118,7 @@ export const Scatter = React.forwardRef( onActiveChange, analyticsName, interactive = true, + initialWidth, className, ...props }, @@ -126,7 +132,7 @@ export const Scatter = React.forwardRef( onClickDatum ? scatterClickMeta : undefined, ); - const { width, attachRef } = useResizeWidth(); + const { width, attachRef } = useResizeWidth(initialWidth); const tooltipRef = React.useRef(null); const [activeDot, setActiveDot] = React.useState(null); diff --git a/packages/origin/src/components/Chart/Sparkline.tsx b/packages/origin/src/components/Chart/Sparkline.tsx index 2d5dbcc65..9e5237275 100644 --- a/packages/origin/src/components/Chart/Sparkline.tsx +++ b/packages/origin/src/components/Chart/Sparkline.tsx @@ -25,13 +25,14 @@ const SparklineBar = React.forwardRef( dataKey, color, height = 40, + initialWidth, className, analyticsName: _analyticsName, ...props }, ref, ) { - const { width, attachRef } = useResizeWidth(); + const { width, attachRef } = useResizeWidth(initialWidth); const mergedRef = useMergedRef(ref, attachRef); const key = dataKey ?? "value"; diff --git a/packages/origin/src/components/Chart/StackedAreaChart.tsx b/packages/origin/src/components/Chart/StackedAreaChart.tsx index 9090a9840..09e7f6d50 100644 --- a/packages/origin/src/components/Chart/StackedAreaChart.tsx +++ b/packages/origin/src/components/Chart/StackedAreaChart.tsx @@ -41,6 +41,11 @@ const clickIndexMeta = (index: number) => ({ index }); export interface StackedAreaChartProps extends React.ComponentPropsWithoutRef<"div"> { data: ChartDatum[]; + /** + * Pre-measurement width in pixels. Used as a fallback before + * ResizeObserver fires, enabling server-side rendering. + */ + initialWidth?: number; series: [Series, Series, ...Series[]]; xKey?: string; height?: number; @@ -102,12 +107,13 @@ export const StackedArea = React.forwardRef< formatValue, formatXLabel, formatYLabel, + initialWidth, className, ...props }, ref, ) { - const { width, attachRef } = useResizeWidth(); + const { width, attachRef } = useResizeWidth(initialWidth); const trackedClick = useTrackedCallback( analyticsName, "Chart.StackedArea", diff --git a/packages/origin/src/components/Chart/WaterfallChart.tsx b/packages/origin/src/components/Chart/WaterfallChart.tsx index 9685adb81..13a3dba54 100644 --- a/packages/origin/src/components/Chart/WaterfallChart.tsx +++ b/packages/origin/src/components/Chart/WaterfallChart.tsx @@ -28,6 +28,11 @@ export interface WaterfallSegment { export interface WaterfallChartProps extends React.ComponentPropsWithoutRef<"div"> { data: WaterfallSegment[]; + /** + * Pre-measurement width in pixels. Used as a fallback before + * ResizeObserver fires, enabling server-side rendering. + */ + initialWidth?: number; formatValue?: (value: number) => string; formatYLabel?: (value: number) => string; showConnectors?: boolean; @@ -86,12 +91,13 @@ export const Waterfall = React.forwardRef( onActiveChange, analyticsName, interactive: interactiveProp = true, + initialWidth, className, ...props }, ref, ) { - const { width, attachRef } = useResizeWidth(); + const { width, attachRef } = useResizeWidth(initialWidth); const tooltipRef = React.useRef(null); const [activeIndex, setActiveIndex] = React.useState(null); diff --git a/packages/origin/src/components/Chart/hooks.ts b/packages/origin/src/components/Chart/hooks.ts index 2a4a12bf3..082b5da93 100644 --- a/packages/origin/src/components/Chart/hooks.ts +++ b/packages/origin/src/components/Chart/hooks.ts @@ -3,19 +3,19 @@ import type { CurveInterpolator } from "./utils"; import type { ChartDatum, TooltipMode } from "./types"; import { PAD_RIGHT, TOOLTIP_GAP } from "./types"; -export function useResizeWidth() { - const [width, setWidth] = React.useState(0); +export function useResizeWidth(initialWidth?: number) { + const [measuredWidth, setMeasuredWidth] = React.useState(null); const observerRef = React.useRef(null); const attachRef = React.useCallback((node: HTMLDivElement | null) => { observerRef.current?.disconnect(); if (node) { const observer = new ResizeObserver((entries) => { - for (const entry of entries) setWidth(entry.contentRect.width); + for (const entry of entries) setMeasuredWidth(entry.contentRect.width); }); observer.observe(node); observerRef.current = observer; - setWidth(node.clientWidth); + setMeasuredWidth(node.clientWidth); } }, []); @@ -23,6 +23,7 @@ export function useResizeWidth() { return () => observerRef.current?.disconnect(); }, []); + const width = measuredWidth !== null ? measuredWidth : initialWidth ?? 0; return { width, attachRef }; }