Skip to content

Commit ad2d266

Browse files
authored
Merge pull request #42 from diegopeixoto/31-canvas-does-not-resize-when-container-size-changes
2 parents ccf405f + c840ee8 commit ad2d266

File tree

6 files changed

+216
-45
lines changed

6 files changed

+216
-45
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ yarn-error.log*
3131
.vscode/
3232
.idea/
3333

34+
# Playground
35+
playground/
36+
3437
# TypeScript
3538
*.tsbuildinfo
3639

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "unicornstudio-react",
3-
"version": "2.1.4",
3+
"version": "2.1.4-1",
44
"description": "React component for embedding Unicorn.Studio interactive scenes with TypeScript support. Compatible with React (Vite) and Next.js.",
55
"keywords": [
66
"react",

src/__tests__/next-component.test.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,17 @@ describe("UnicornScene (Next.js)", () => {
5757
});
5858

5959
it("passes sdkUrl to the Script component", () => {
60-
render(<UnicornScene projectId="test-id" sdkUrl="https://custom-cdn.example.com/sdk.js" />);
60+
render(
61+
<UnicornScene
62+
projectId="test-id"
63+
sdkUrl="https://custom-cdn.example.com/sdk.js"
64+
/>,
65+
);
6166
const script = screen.getByTestId("next-script");
62-
expect(script).toHaveAttribute("src", "https://custom-cdn.example.com/sdk.js");
67+
expect(script).toHaveAttribute(
68+
"src",
69+
"https://custom-cdn.example.com/sdk.js",
70+
);
6371
});
6472

6573
it("uses lazyOnload strategy when lazyLoad is true", () => {

src/__tests__/setup.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,38 @@
11
import "@testing-library/jest-dom/vitest";
2+
import { vi } from "vitest";
3+
4+
// jsdom does not provide ResizeObserver — provide a minimal mock that tracks
5+
// observed elements and exposes a helper to simulate resize entries.
6+
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
7+
8+
class MockResizeObserver {
9+
static instances: MockResizeObserver[] = [];
10+
11+
callback: ResizeCallback;
12+
elements: Set<Element> = new Set();
13+
14+
constructor(callback: ResizeCallback) {
15+
this.callback = callback;
16+
MockResizeObserver.instances.push(this);
17+
}
18+
19+
observe(target: Element) {
20+
this.elements.add(target);
21+
}
22+
unobserve(target: Element) {
23+
this.elements.delete(target);
24+
}
25+
disconnect = vi.fn(() => {
26+
this.elements.clear();
27+
});
28+
29+
/** Test helper: fire the callback with a minimal entry for `target`. */
30+
simulateResize(target: Element) {
31+
const entry = { target } as ResizeObserverEntry;
32+
this.callback([entry]);
33+
}
34+
}
35+
36+
vi.stubGlobal("ResizeObserver", MockResizeObserver);
37+
38+
export { MockResizeObserver };

src/__tests__/useUnicornScene.test.ts

Lines changed: 149 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
22
import { renderHook, act } from "@testing-library/react";
33
import { useUnicornScene } from "../shared/hooks";
44
import type { UnicornStudioScene } from "../shared/types";
5+
import { MockResizeObserver } from "./setup";
56

67
// ---------------------------------------------------------------------------
78
// Helpers
@@ -62,6 +63,7 @@ describe("useUnicornScene", () => {
6263
vi.restoreAllMocks();
6364
delete (window as Record<string, unknown>).UnicornStudio;
6465
containerEl.remove();
66+
MockResizeObserver.instances.length = 0;
6567
});
6668

6769
// -----------------------------------------------------------------------
@@ -103,9 +105,7 @@ describe("useUnicornScene", () => {
103105
addSceneMock.mockResolvedValueOnce(scene);
104106
const onLoad = vi.fn();
105107

106-
renderHook(() =>
107-
useUnicornScene({ ...defaultProps(elementRef), onLoad }),
108-
);
108+
renderHook(() => useUnicornScene({ ...defaultProps(elementRef), onLoad }));
109109

110110
await act(async () => {});
111111

@@ -135,10 +135,9 @@ describe("useUnicornScene", () => {
135135
const scene = createMockScene();
136136
addSceneMock.mockResolvedValue(scene);
137137

138-
const { rerender } = renderHook(
139-
(props) => useUnicornScene(props),
140-
{ initialProps: { ...defaultProps(elementRef), onLoad: () => {} } },
141-
);
138+
const { rerender } = renderHook((props) => useUnicornScene(props), {
139+
initialProps: { ...defaultProps(elementRef), onLoad: () => {} },
140+
});
142141

143142
await act(async () => {});
144143
expect(addSceneMock).toHaveBeenCalledTimes(1);
@@ -160,10 +159,9 @@ describe("useUnicornScene", () => {
160159
const scene2 = createMockScene();
161160
addSceneMock.mockResolvedValueOnce(scene1).mockResolvedValueOnce(scene2);
162161

163-
const { rerender } = renderHook(
164-
(props) => useUnicornScene(props),
165-
{ initialProps: defaultProps(elementRef) },
166-
);
162+
const { rerender } = renderHook((props) => useUnicornScene(props), {
163+
initialProps: defaultProps(elementRef),
164+
});
167165

168166
await act(async () => {});
169167
expect(addSceneMock).toHaveBeenCalledTimes(1);
@@ -206,14 +204,16 @@ describe("useUnicornScene", () => {
206204
let resolveFirst!: (s: UnicornStudioScene) => void;
207205
addSceneMock
208206
.mockImplementationOnce(
209-
() => new Promise<UnicornStudioScene>((r) => { resolveFirst = r; }),
207+
() =>
208+
new Promise<UnicornStudioScene>((r) => {
209+
resolveFirst = r;
210+
}),
210211
)
211212
.mockResolvedValueOnce(createMockScene());
212213

213-
const { rerender } = renderHook(
214-
(props) => useUnicornScene(props),
215-
{ initialProps: defaultProps(elementRef) },
216-
);
214+
const { rerender } = renderHook((props) => useUnicornScene(props), {
215+
initialProps: defaultProps(elementRef),
216+
});
217217

218218
await act(async () => {});
219219
expect(addSceneMock).toHaveBeenCalledTimes(1);
@@ -226,7 +226,9 @@ describe("useUnicornScene", () => {
226226

227227
// Resolve the first (its ignore flag is set, so the scene gets destroyed)
228228
const lateScene = createMockScene();
229-
await act(async () => { resolveFirst(lateScene); });
229+
await act(async () => {
230+
resolveFirst(lateScene);
231+
});
230232
expect(lateScene.destroy).toHaveBeenCalled();
231233
});
232234

@@ -279,15 +281,12 @@ describe("useUnicornScene", () => {
279281
const oldRef: { current: UnicornStudioScene | null } = { current: null };
280282
const newRef: { current: UnicornStudioScene | null } = { current: null };
281283

282-
const { rerender } = renderHook(
283-
(props) => useUnicornScene(props),
284-
{
285-
initialProps: {
286-
...defaultProps(elementRef),
287-
sceneRef: oldRef,
288-
},
284+
const { rerender } = renderHook((props) => useUnicornScene(props), {
285+
initialProps: {
286+
...defaultProps(elementRef),
287+
sceneRef: oldRef,
289288
},
290-
);
289+
});
291290

292291
await act(async () => {});
293292
expect(oldRef.current).toBe(scene);
@@ -307,15 +306,12 @@ describe("useUnicornScene", () => {
307306
const oldRefFn = vi.fn();
308307
const newRefFn = vi.fn();
309308

310-
const { rerender } = renderHook(
311-
(props) => useUnicornScene(props),
312-
{
313-
initialProps: {
314-
...defaultProps(elementRef),
315-
sceneRef: oldRefFn,
316-
},
309+
const { rerender } = renderHook((props) => useUnicornScene(props), {
310+
initialProps: {
311+
...defaultProps(elementRef),
312+
sceneRef: oldRefFn,
317313
},
318-
);
314+
});
319315

320316
await act(async () => {});
321317
expect(oldRefFn).toHaveBeenCalledWith(scene);
@@ -337,10 +333,9 @@ describe("useUnicornScene", () => {
337333
const scene = createMockScene({ paused: false });
338334
addSceneMock.mockResolvedValueOnce(scene);
339335

340-
const { rerender } = renderHook(
341-
(props) => useUnicornScene(props),
342-
{ initialProps: { ...defaultProps(elementRef), paused: false } },
343-
);
336+
const { rerender } = renderHook((props) => useUnicornScene(props), {
337+
initialProps: { ...defaultProps(elementRef), paused: false },
338+
});
344339

345340
await act(async () => {});
346341

@@ -407,7 +402,10 @@ describe("useUnicornScene", () => {
407402
const scene = createMockScene();
408403
let resolveAddScene!: (s: UnicornStudioScene) => void;
409404
addSceneMock.mockImplementationOnce(
410-
() => new Promise<UnicornStudioScene>((r) => { resolveAddScene = r; }),
405+
() =>
406+
new Promise<UnicornStudioScene>((r) => {
407+
resolveAddScene = r;
408+
}),
411409
);
412410

413411
const { unmount } = renderHook(() =>
@@ -435,10 +433,9 @@ describe("useUnicornScene", () => {
435433
addSceneMock.mockResolvedValue(scene);
436434

437435
const props = defaultProps(elementRef);
438-
const { rerender } = renderHook(
439-
(p) => useUnicornScene(p),
440-
{ initialProps: props },
441-
);
436+
const { rerender } = renderHook((p) => useUnicornScene(p), {
437+
initialProps: props,
438+
});
442439

443440
await act(async () => {});
444441
expect(addSceneMock).toHaveBeenCalledTimes(1);
@@ -581,4 +578,114 @@ describe("useUnicornScene", () => {
581578
expect(onError).toHaveBeenCalled();
582579
expect(result.current.error).toBeInstanceOf(Error);
583580
});
581+
582+
// -----------------------------------------------------------------------
583+
// ResizeObserver – container resize triggers scene.resize()
584+
// -----------------------------------------------------------------------
585+
586+
it("calls scene.resize() when the container is resized", async () => {
587+
const resizeFn = vi.fn();
588+
const scene = createMockScene({ resize: resizeFn });
589+
addSceneMock.mockResolvedValueOnce(scene);
590+
591+
renderHook(() => useUnicornScene(defaultProps(elementRef)));
592+
593+
await act(async () => {});
594+
595+
// Find the observer watching our container
596+
const observer = MockResizeObserver.instances.find((o) =>
597+
o.elements.has(containerEl),
598+
);
599+
expect(observer).toBeDefined();
600+
601+
// Simulate a container resize
602+
act(() => {
603+
observer!.simulateResize(containerEl);
604+
});
605+
606+
expect(resizeFn).toHaveBeenCalledTimes(1);
607+
});
608+
609+
it("handles scene.resize being undefined gracefully", async () => {
610+
const scene = createMockScene({ resize: undefined });
611+
addSceneMock.mockResolvedValueOnce(scene);
612+
613+
renderHook(() => useUnicornScene(defaultProps(elementRef)));
614+
615+
await act(async () => {});
616+
617+
const observer = MockResizeObserver.instances.find((o) =>
618+
o.elements.has(containerEl),
619+
);
620+
621+
// Should not throw when resize is undefined
622+
expect(() => {
623+
act(() => {
624+
observer!.simulateResize(containerEl);
625+
});
626+
}).not.toThrow();
627+
});
628+
629+
it("disconnects the ResizeObserver on unmount", async () => {
630+
const scene = createMockScene({ resize: vi.fn() });
631+
addSceneMock.mockResolvedValueOnce(scene);
632+
633+
const { unmount } = renderHook(() =>
634+
useUnicornScene(defaultProps(elementRef)),
635+
);
636+
637+
await act(async () => {});
638+
639+
const observer = MockResizeObserver.instances.find((o) =>
640+
o.elements.has(containerEl),
641+
);
642+
expect(observer).toBeDefined();
643+
644+
unmount();
645+
646+
expect(observer!.disconnect).toHaveBeenCalled();
647+
});
648+
649+
it("degrades gracefully when ResizeObserver is unavailable", async () => {
650+
const originalResizeObserver = globalThis.ResizeObserver;
651+
// @ts-expect-error -- simulating an environment without ResizeObserver
652+
delete globalThis.ResizeObserver;
653+
654+
try {
655+
addSceneMock.mockResolvedValueOnce(createMockScene({ resize: vi.fn() }));
656+
657+
const { unmount } = renderHook(() =>
658+
useUnicornScene(defaultProps(elementRef)),
659+
);
660+
661+
await act(async () => {});
662+
663+
// No ResizeObserver should have been created
664+
expect(MockResizeObserver.instances).toHaveLength(0);
665+
666+
// Should not throw on unmount either
667+
expect(() => unmount()).not.toThrow();
668+
} finally {
669+
globalThis.ResizeObserver = originalResizeObserver;
670+
}
671+
});
672+
673+
it("does not create a ResizeObserver when elementRef is null", async () => {
674+
const instancesBefore = MockResizeObserver.instances.length;
675+
676+
renderHook(() =>
677+
useUnicornScene({
678+
...defaultProps({ current: null }),
679+
isScriptLoaded: true,
680+
}),
681+
);
682+
683+
await act(async () => {});
684+
685+
const newObservers = MockResizeObserver.instances.slice(instancesBefore);
686+
const observersWithElements = newObservers.filter(
687+
(o) => o.elements.size > 0,
688+
);
689+
expect(observersWithElements).toHaveLength(0);
690+
});
584691
});

src/shared/hooks.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,5 +414,21 @@ export function useUnicornScene({
414414
}
415415
}, [paused]);
416416

417+
// Observe container resize and call scene.resize() so the canvas adapts
418+
useEffect(() => {
419+
const el = elementRef.current;
420+
if (!el || typeof ResizeObserver === "undefined") return;
421+
422+
const observer = new ResizeObserver(() => {
423+
internalSceneRef.current?.resize?.();
424+
});
425+
426+
observer.observe(el);
427+
428+
return () => {
429+
observer.disconnect();
430+
};
431+
}, [elementRef]);
432+
417433
return { error: initError };
418434
}

0 commit comments

Comments
 (0)