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
5 changes: 5 additions & 0 deletions .changeset/giant-tips-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lightsparkdev/origin": patch
---

- Add initialWidth prop to Origin charts for SSR support
8 changes: 7 additions & 1 deletion packages/origin/src/components/Chart/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,12 +121,13 @@ export const Bar = React.forwardRef<HTMLDivElement, BarChartProps>(function Bar(
animate = true,
getBarColor,
orientation = "vertical",
initialWidth,
className,
...props
},
ref,
) {
const { width, attachRef } = useResizeWidth();
const { width, attachRef } = useResizeWidth(initialWidth);
const tooltipRef = React.useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);

Expand Down
2 changes: 2 additions & 0 deletions packages/origin/src/components/Chart/Chart.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const Line: Story = {
fill: false,
fadeLeft: false,
compareLabel: "",
initialWidth: undefined,
},
argTypes: {
curve: { control: "radio", options: ["monotone", "linear"] },
Expand Down Expand Up @@ -228,6 +229,7 @@ export const BarGrouped: Story = {
legend: false,
loading: false,
stacked: false,
initialWidth: undefined,
},
argTypes: {
orientation: { control: "radio", options: ["vertical", "horizontal"] },
Expand Down
65 changes: 65 additions & 0 deletions packages/origin/src/components/Chart/Chart.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
});
8 changes: 7 additions & 1 deletion packages/origin/src/components/Chart/ComposedChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -130,12 +135,13 @@ export const Composed = React.forwardRef<HTMLDivElement, ComposedChartProps>(
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",
Expand Down
8 changes: 7 additions & 1 deletion packages/origin/src/components/Chart/FunnelChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -61,12 +66,13 @@ export const Funnel = React.forwardRef<HTMLDivElement, FunnelChartProps>(
onClickDatum,
onActiveChange,
analyticsName,
initialWidth,
className,
...props
},
ref,
) {
const { width, attachRef } = useResizeWidth();
const { width, attachRef } = useResizeWidth(initialWidth);
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);

const onActiveChangeRef = React.useRef(onActiveChange);
Expand Down
8 changes: 7 additions & 1 deletion packages/origin/src/components/Chart/LineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -150,12 +155,13 @@ export const Line = React.forwardRef<HTMLDivElement, LineChartProps>(
formatXLabel,
formatYLabel,
connectNulls = true,
initialWidth,
className,
...props
},
ref,
) {
const { width, attachRef } = useResizeWidth();
const { width, attachRef } = useResizeWidth(initialWidth);
const trackedClick = useTrackedCallback(
analyticsName,
"Chart.Line",
Expand Down
8 changes: 7 additions & 1 deletion packages/origin/src/components/Chart/PieChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -107,6 +112,7 @@ export const Pie = React.forwardRef<HTMLDivElement, PieChartProps>(function Pie(
analyticsName,
ariaLabel,
formatValue,
initialWidth,
className,
...props
},
Expand All @@ -123,7 +129,7 @@ export const Pie = React.forwardRef<HTMLDivElement, PieChartProps>(function Pie(
onClickDatum ? clickIndexMeta : undefined,
);

const { width, attachRef } = useResizeWidth();
const { width, attachRef } = useResizeWidth(initialWidth);
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);

const mergedRef = useMergedRef(ref, attachRef);
Expand Down
8 changes: 7 additions & 1 deletion packages/origin/src/components/Chart/SankeyChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,6 +83,7 @@ export const Sankey = React.forwardRef<HTMLDivElement, SankeyChartProps>(
onClickNode,
onClickLink,
analyticsName,
initialWidth,
className,
...props
},
Expand All @@ -98,7 +104,7 @@ export const Sankey = React.forwardRef<HTMLDivElement, SankeyChartProps>(
onClickLink ? sankeyLinkClickMeta : undefined,
);

const { width, attachRef } = useResizeWidth();
const { width, attachRef } = useResizeWidth(initialWidth);
const [active, setActive] = React.useState<ActiveElement>(null);
const tooltipRef = React.useRef<HTMLDivElement>(null);
const rootRef = React.useRef<HTMLDivElement | null>(null);
Expand Down
8 changes: 7 additions & 1 deletion packages/origin/src/components/Chart/ScatterChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -113,6 +118,7 @@ export const Scatter = React.forwardRef<HTMLDivElement, ScatterChartProps>(
onActiveChange,
analyticsName,
interactive = true,
initialWidth,
className,
...props
},
Expand All @@ -126,7 +132,7 @@ export const Scatter = React.forwardRef<HTMLDivElement, ScatterChartProps>(
onClickDatum ? scatterClickMeta : undefined,
);

const { width, attachRef } = useResizeWidth();
const { width, attachRef } = useResizeWidth(initialWidth);
const tooltipRef = React.useRef<HTMLDivElement>(null);
const [activeDot, setActiveDot] = React.useState<ActiveDot | null>(null);

Expand Down
3 changes: 2 additions & 1 deletion packages/origin/src/components/Chart/Sparkline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ const SparklineBar = React.forwardRef<HTMLDivElement, SparklineProps>(
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";
Expand Down
8 changes: 7 additions & 1 deletion packages/origin/src/components/Chart/StackedAreaChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/origin/src/components/Chart/WaterfallChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -86,12 +91,13 @@ export const Waterfall = React.forwardRef<HTMLDivElement, WaterfallChartProps>(
onActiveChange,
analyticsName,
interactive: interactiveProp = true,
initialWidth,
className,
...props
},
ref,
) {
const { width, attachRef } = useResizeWidth();
const { width, attachRef } = useResizeWidth(initialWidth);
const tooltipRef = React.useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);

Expand Down
9 changes: 5 additions & 4 deletions packages/origin/src/components/Chart/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,27 @@ 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<number | null>(null);
const observerRef = React.useRef<ResizeObserver | null>(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);
}
}, []);

React.useEffect(() => {
return () => observerRef.current?.disconnect();
}, []);

const width = measuredWidth !== null ? measuredWidth : initialWidth ?? 0;
return { width, attachRef };
}

Expand Down
Loading