Skip to content
Open
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
180 changes: 180 additions & 0 deletions packages/app/src/hooks/useResponsiveChartDimensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// @vitest-environment jsdom
import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
useResponsiveChartDimensions,
type UseResponsiveChartDimensionsResult,
} from './useResponsiveChartDimensions';

// Minimal ResizeObserver stand-in — jsdom doesn't implement it. Tests fire
// observations manually via `trigger`.
class MockResizeObserver {
static instances: MockResizeObserver[] = [];
callback: ResizeObserverCallback;
observed: Element[] = [];
disconnected = false;

constructor(callback: ResizeObserverCallback) {
this.callback = callback;
MockResizeObserver.instances.push(this);
}

observe(el: Element) {
this.observed.push(el);
}

disconnect() {
this.disconnected = true;
}

unobserve() {}

trigger(width: number) {
act(() => {
this.callback(
[{ contentRect: { width } } as unknown as ResizeObserverEntry],
this as unknown as ResizeObserver,
);
});
}
}

function renderHook<T>(hook: () => T): {
result: { current: T };
rerender: () => void;
unmount: () => void;
root: Root;
} {
const result = { current: undefined as unknown as T };
function TestComponent() {
result.current = hook();
return null;
}
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
const render = () => {
act(() => {
root.render(createElement(TestComponent));
});
};
render();
return {
result,
rerender: render,
unmount: () => {
act(() => root.unmount());
container.remove();
},
root,
};
}

/** Create a container div whose getBoundingClientRect reports `width`. */
function makeContainer(width: number): HTMLDivElement {
const el = document.createElement('div');
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ width } as DOMRect);
return el;
}

beforeEach(() => {
MockResizeObserver.instances = [];
vi.stubGlobal('ResizeObserver', MockResizeObserver);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('useResponsiveChartDimensions', () => {
it('measures the container on attach', () => {
const { result, unmount } = renderHook<UseResponsiveChartDimensionsResult>(() =>
useResponsiveChartDimensions({ height: 600 }),
);

act(() => {
result.current.setContainerRef(makeContainer(800));
});

expect(result.current.dimensions).toEqual({ width: 800, height: 600 });
unmount();
});

it('keeps the dimensions object identity when an observation reports the same size', () => {
const { result, unmount } = renderHook<UseResponsiveChartDimensionsResult>(() =>
useResponsiveChartDimensions({ height: 600 }),
);

act(() => {
result.current.setContainerRef(makeContainer(800));
});
const initial = result.current.dimensions;

// ResizeObserver fires once right after observe() with the width the ref
// callback already measured. The object identity must not change — a new
// identity makes every chart treat it as a resize and fully rebuild.
MockResizeObserver.instances.at(-1)!.trigger(800);

expect(result.current.dimensions).toBe(initial);
unmount();
});

it('updates dimensions when an observation reports a new width', () => {
const { result, unmount } = renderHook<UseResponsiveChartDimensionsResult>(() =>
useResponsiveChartDimensions({ height: 600 }),
);

act(() => {
result.current.setContainerRef(makeContainer(800));
});
const initial = result.current.dimensions;

MockResizeObserver.instances.at(-1)!.trigger(1024);

expect(result.current.dimensions).not.toBe(initial);
expect(result.current.dimensions).toEqual({ width: 1024, height: 600 });
unmount();
});

it('disconnects the previous observer when the container changes', () => {
const { result, unmount } = renderHook<UseResponsiveChartDimensionsResult>(() =>
useResponsiveChartDimensions({ height: 600 }),
);

act(() => {
result.current.setContainerRef(makeContainer(800));
});
const first = MockResizeObserver.instances.at(-1)!;

act(() => {
result.current.setContainerRef(makeContainer(640));
});

expect(first.disconnected).toBe(true);
expect(result.current.dimensions).toEqual({ width: 640, height: 600 });
unmount();
});

it('detaches cleanly when the container is removed', () => {
const { result, unmount } = renderHook<UseResponsiveChartDimensionsResult>(() =>
useResponsiveChartDimensions({ height: 600 }),
);

act(() => {
result.current.setContainerRef(makeContainer(800));
});
const observer = MockResizeObserver.instances.at(-1)!;

act(() => {
result.current.setContainerRef(null);
});

expect(observer.disconnected).toBe(true);
// Last measured dimensions are retained (no reset to 0 on detach).
expect(result.current.dimensions).toEqual({ width: 800, height: 600 });
unmount();
});
});
19 changes: 15 additions & 4 deletions packages/app/src/hooks/useResponsiveChartDimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ export function useResponsiveChartDimensions(
const [dimensions, setDimensions] = useState({ width: 0, height });
const resizeObserverRef = useRef<ResizeObserver | null>(null);

// Keep the dimensions object referentially stable when nothing changed.
// ResizeObserver fires once right after observe() with the same width the
// ref callback just measured — without this guard that initial callback
// produces a new object identity, which downstream chart effects treat as
// a resize and answer with a full (visually identical) D3 rebuild.
const updateDimensions = useCallback((width: number, h: number) => {
setDimensions((prev) =>
prev.width === width && prev.height === h ? prev : { width, height: h },
);
}, []);

// ref callback for initial dimension calculation and ResizeObserver setup
const setContainerRef = useCallback(
(element: HTMLDivElement | null) => {
Expand All @@ -47,20 +58,20 @@ export function useResponsiveChartDimensions(
if (element) {
// set initial dimensions
const initialWidth = element.getBoundingClientRect().width;
setDimensions({ width: initialWidth, height });
updateDimensions(initialWidth, height);

// set up ResizeObserver
resizeObserverRef.current = new ResizeObserver((entries) => {
if (entries[0]) {
const { width: observedWidth } = entries[0].contentRect;
setDimensions({ width: observedWidth, height });
updateDimensions(observedWidth, height);
}
});

resizeObserverRef.current.observe(element);
}
},
[height],
[height, updateDimensions],
);

// clean up on unmount or height change
Expand All @@ -75,7 +86,7 @@ export function useResponsiveChartDimensions(

// update dimensions when height changes
useEffect(() => {
setDimensions((prev) => ({ ...prev, height }));
setDimensions((prev) => (prev.height === height ? prev : { ...prev, height }));
}, [height]);

return {
Expand Down
Loading
Loading