Skip to content

Commit 5ef5b36

Browse files
github-actions[bot]jaymantricoreymartin
authored
Add initialWidth prop to Origin charts for SSR support
## Summary - Add opti (#512) * Add `initialWidth` prop to Origin charts for SSR support (#25548) ## Summary - Add optional `initialWidth` prop to all SVG-based chart components (Line, Bar, StackedArea, Pie, Scatter, Composed, Funnel, Waterfall, Sankey, SparklineBar) to enable server-side rendering via `renderToStaticMarkup` - Update the shared `useResizeWidth` hook to accept an `initialWidth` fallback — used when no `ResizeObserver` measurement is available (e.g. Node.js environments), with the observer taking over seamlessly on the client - Rename the existing partial `width` prop on LineChart and BarChart to `initialWidth` for consistent semantics across all charts ## Motivation Charts currently render with 0 width in Node.js because `ResizeObserver` is unavailable. This blocks the Lighthouse team's pipeline for generating chart PNGs for Slack: `renderToStaticMarkup` → SVG string → `sharp`/`resvg` → PNG. The `initialWidth` prop provides a pre-measurement fallback with zero breaking changes — existing consumers are unaffected. ## Test plan - [x] Unit tests added for `useResizeWidth` fallback behavior (4 new tests) - [x] `initialWidth` added to Line and BarGrouped Storybook args for discoverability - [x] `yarn build` passes - [x] `yarn test:unit` passes (420 tests, 7 files) - [x] `yarn lint` passes (0 errors) - [x] `yarn format` passes Made with [Cursor](https://cursor.com) GitOrigin-RevId: c7671c95535be6ce26fa402e03a0db4533265822 * Create giant-tips-laugh.md --------- Co-authored-by: Jay Mantri <amantri08@gmail.com> Co-authored-by: Corey Martin <coreyn.martin@gmail.com>
1 parent 25a132d commit 5ef5b36

14 files changed

Lines changed: 142 additions & 14 deletions

.changeset/giant-tips-laugh.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@lightsparkdev/origin": patch
3+
---
4+
5+
- Add initialWidth prop to Origin charts for SSR support

packages/origin/src/components/Chart/BarChart.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ const clickIndexMeta = (index: number) => ({ index });
4141

4242
export interface BarChartProps extends React.ComponentPropsWithoutRef<"div"> {
4343
data: ChartDatum[];
44+
/**
45+
* Pre-measurement width in pixels. Used as a fallback before
46+
* ResizeObserver fires, enabling server-side rendering.
47+
*/
48+
initialWidth?: number;
4449
dataKey?: string;
4550
series?: Series[];
4651
xKey?: string;
@@ -116,12 +121,13 @@ export const Bar = React.forwardRef<HTMLDivElement, BarChartProps>(function Bar(
116121
animate = true,
117122
getBarColor,
118123
orientation = "vertical",
124+
initialWidth,
119125
className,
120126
...props
121127
},
122128
ref,
123129
) {
124-
const { width, attachRef } = useResizeWidth();
130+
const { width, attachRef } = useResizeWidth(initialWidth);
125131
const tooltipRef = React.useRef<HTMLDivElement>(null);
126132
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
127133

packages/origin/src/components/Chart/Chart.stories.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const Line: Story = {
4444
fill: false,
4545
fadeLeft: false,
4646
compareLabel: "",
47+
initialWidth: undefined,
4748
},
4849
argTypes: {
4950
curve: { control: "radio", options: ["monotone", "linear"] },
@@ -228,6 +229,7 @@ export const BarGrouped: Story = {
228229
legend: false,
229230
loading: false,
230231
stacked: false,
232+
initialWidth: undefined,
231233
},
232234
argTypes: {
233235
orientation: { control: "radio", options: ["vertical", "horizontal"] },

packages/origin/src/components/Chart/Chart.unit.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
*/
88

99
import { describe, it, expect, vi } from "vitest";
10+
import { renderHook, act } from "@testing-library/react";
11+
import { useResizeWidth } from "./hooks";
1012
import {
1113
linearScale,
1214
niceTicks,
@@ -871,3 +873,66 @@ describe("sankeyLinkPath", () => {
871873
expect(path).toContain("C");
872874
});
873875
});
876+
877+
// ---------------------------------------------------------------------------
878+
// useResizeWidth
879+
// ---------------------------------------------------------------------------
880+
881+
describe("useResizeWidth", () => {
882+
it("returns 0 when called with no arguments", () => {
883+
const { result } = renderHook(() => useResizeWidth());
884+
expect(result.current.width).toBe(0);
885+
expect(typeof result.current.attachRef).toBe("function");
886+
});
887+
888+
it("returns initialWidth when no observer has fired", () => {
889+
const { result } = renderHook(() => useResizeWidth(800));
890+
expect(result.current.width).toBe(800);
891+
});
892+
893+
it("returns initialWidth for various values", () => {
894+
const { result: r1 } = renderHook(() => useResizeWidth(1024));
895+
expect(r1.current.width).toBe(1024);
896+
897+
const { result: r2 } = renderHook(() => useResizeWidth(320));
898+
expect(r2.current.width).toBe(320);
899+
});
900+
901+
it("returns 0 when initialWidth is 0", () => {
902+
const { result } = renderHook(() => useResizeWidth(0));
903+
expect(result.current.width).toBe(0);
904+
});
905+
906+
it("observer measurement takes over from initialWidth", () => {
907+
let observerCallback: ResizeObserverCallback;
908+
const mockObserver = {
909+
observe: vi.fn(),
910+
disconnect: vi.fn(),
911+
unobserve: vi.fn(),
912+
};
913+
vi.stubGlobal(
914+
"ResizeObserver",
915+
vi.fn((cb: ResizeObserverCallback) => {
916+
observerCallback = cb;
917+
return mockObserver;
918+
}),
919+
);
920+
921+
const { result } = renderHook(() => useResizeWidth(800));
922+
expect(result.current.width).toBe(800);
923+
924+
const fakeNode = { clientWidth: 0 } as HTMLDivElement;
925+
act(() => result.current.attachRef(fakeNode));
926+
927+
act(() => {
928+
observerCallback(
929+
[{ contentRect: { width: 600 } }] as ResizeObserverEntry[],
930+
{} as ResizeObserver,
931+
);
932+
});
933+
934+
expect(result.current.width).toBe(600);
935+
936+
vi.unstubAllGlobals();
937+
});
938+
});

packages/origin/src/components/Chart/ComposedChart.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ type ResolvedComposedSeries = {
5959
export interface ComposedChartProps
6060
extends React.ComponentPropsWithoutRef<"div"> {
6161
data: ChartDatum[];
62+
/**
63+
* Pre-measurement width in pixels. Used as a fallback before
64+
* ResizeObserver fires, enabling server-side rendering.
65+
*/
66+
initialWidth?: number;
6267
series: ComposedSeries[];
6368
xKey?: string;
6469
height?: number;
@@ -130,12 +135,13 @@ export const Composed = React.forwardRef<HTMLDivElement, ComposedChartProps>(
130135
connectNulls = true,
131136
yDomain: yDomainProp,
132137
yDomainRight: yDomainRightProp,
138+
initialWidth,
133139
className,
134140
...props
135141
},
136142
ref,
137143
) {
138-
const { width, attachRef } = useResizeWidth();
144+
const { width, attachRef } = useResizeWidth(initialWidth);
139145
const trackedClick = useTrackedCallback(
140146
analyticsName,
141147
"Chart.Composed",

packages/origin/src/components/Chart/FunnelChart.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export interface FunnelStage {
1919
export interface FunnelChartProps
2020
extends React.ComponentPropsWithoutRef<"div"> {
2121
data: FunnelStage[];
22+
/**
23+
* Pre-measurement width in pixels. Used as a fallback before
24+
* ResizeObserver fires, enabling server-side rendering.
25+
*/
26+
initialWidth?: number;
2227
formatValue?: (value: number) => string;
2328
formatRate?: (rate: number) => string;
2429
showRates?: boolean;
@@ -61,12 +66,13 @@ export const Funnel = React.forwardRef<HTMLDivElement, FunnelChartProps>(
6166
onClickDatum,
6267
onActiveChange,
6368
analyticsName,
69+
initialWidth,
6470
className,
6571
...props
6672
},
6773
ref,
6874
) {
69-
const { width, attachRef } = useResizeWidth();
75+
const { width, attachRef } = useResizeWidth(initialWidth);
7076
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
7177

7278
const onActiveChangeRef = React.useRef(onActiveChange);

packages/origin/src/components/Chart/LineChart.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export interface LineChartProps extends React.ComponentPropsWithoutRef<"div"> {
4747
* Array of data objects. Each object should contain keys matching `dataKey` or `series[].key`.
4848
*/
4949
data: ChartDatum[];
50+
/**
51+
* Pre-measurement width in pixels. Used as a fallback before
52+
* ResizeObserver fires, enabling server-side rendering.
53+
*/
54+
initialWidth?: number;
5055
/** Data key for single-series charts. Pass this OR `series`, not both. */
5156
dataKey?: string;
5257
/** Series configuration for multi-series charts. */
@@ -150,12 +155,13 @@ export const Line = React.forwardRef<HTMLDivElement, LineChartProps>(
150155
formatXLabel,
151156
formatYLabel,
152157
connectNulls = true,
158+
initialWidth,
153159
className,
154160
...props
155161
},
156162
ref,
157163
) {
158-
const { width, attachRef } = useResizeWidth();
164+
const { width, attachRef } = useResizeWidth(initialWidth);
159165
const trackedClick = useTrackedCallback(
160166
analyticsName,
161167
"Chart.Line",

packages/origin/src/components/Chart/PieChart.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export interface PieSegment {
1717

1818
export interface PieChartProps extends React.ComponentPropsWithoutRef<"div"> {
1919
data: PieSegment[];
20+
/**
21+
* Pre-measurement width in pixels. Used as a fallback before
22+
* ResizeObserver fires, enabling server-side rendering.
23+
*/
24+
initialWidth?: number;
2025
height?: number;
2126
/** Inner radius ratio (0-1). Defaults to 0.65. */
2227
innerRadius?: number;
@@ -107,6 +112,7 @@ export const Pie = React.forwardRef<HTMLDivElement, PieChartProps>(function Pie(
107112
analyticsName,
108113
ariaLabel,
109114
formatValue,
115+
initialWidth,
110116
className,
111117
...props
112118
},
@@ -123,7 +129,7 @@ export const Pie = React.forwardRef<HTMLDivElement, PieChartProps>(function Pie(
123129
onClickDatum ? clickIndexMeta : undefined,
124130
);
125131

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

129135
const mergedRef = useMergedRef(ref, attachRef);

packages/origin/src/components/Chart/SankeyChart.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ export type { SankeyNode, SankeyLink } from "./sankeyLayout";
2323
export interface SankeyChartProps
2424
extends React.ComponentPropsWithoutRef<"div"> {
2525
data: SankeyData;
26+
/**
27+
* Pre-measurement width in pixels. Used as a fallback before
28+
* ResizeObserver fires, enabling server-side rendering.
29+
*/
30+
initialWidth?: number;
2631
nodeWidth?: number;
2732
nodePadding?: number;
2833
height?: number;
@@ -78,6 +83,7 @@ export const Sankey = React.forwardRef<HTMLDivElement, SankeyChartProps>(
7883
onClickNode,
7984
onClickLink,
8085
analyticsName,
86+
initialWidth,
8187
className,
8288
...props
8389
},
@@ -98,7 +104,7 @@ export const Sankey = React.forwardRef<HTMLDivElement, SankeyChartProps>(
98104
onClickLink ? sankeyLinkClickMeta : undefined,
99105
);
100106

101-
const { width, attachRef } = useResizeWidth();
107+
const { width, attachRef } = useResizeWidth(initialWidth);
102108
const [active, setActive] = React.useState<ActiveElement>(null);
103109
const tooltipRef = React.useRef<HTMLDivElement>(null);
104110
const rootRef = React.useRef<HTMLDivElement | null>(null);

packages/origin/src/components/Chart/ScatterChart.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export interface ScatterSeries {
3939
export interface ScatterChartProps
4040
extends React.ComponentPropsWithoutRef<"div"> {
4141
data: ScatterSeries[];
42+
/**
43+
* Pre-measurement width in pixels. Used as a fallback before
44+
* ResizeObserver fires, enabling server-side rendering.
45+
*/
46+
initialWidth?: number;
4247
height?: number;
4348
grid?: boolean;
4449
tooltip?: TooltipProp;
@@ -113,6 +118,7 @@ export const Scatter = React.forwardRef<HTMLDivElement, ScatterChartProps>(
113118
onActiveChange,
114119
analyticsName,
115120
interactive = true,
121+
initialWidth,
116122
className,
117123
...props
118124
},
@@ -126,7 +132,7 @@ export const Scatter = React.forwardRef<HTMLDivElement, ScatterChartProps>(
126132
onClickDatum ? scatterClickMeta : undefined,
127133
);
128134

129-
const { width, attachRef } = useResizeWidth();
135+
const { width, attachRef } = useResizeWidth(initialWidth);
130136
const tooltipRef = React.useRef<HTMLDivElement>(null);
131137
const [activeDot, setActiveDot] = React.useState<ActiveDot | null>(null);
132138

0 commit comments

Comments
 (0)