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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ yarn-error.log*
.vscode/
.idea/

# Playground
playground/

# TypeScript
*.tsbuildinfo

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "unicornstudio-react",
"version": "2.1.4",
"version": "2.1.4-1",
"description": "React component for embedding Unicorn.Studio interactive scenes with TypeScript support. Compatible with React (Vite) and Next.js.",
"keywords": [
"react",
Expand Down
12 changes: 10 additions & 2 deletions src/__tests__/next-component.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,17 @@ describe("UnicornScene (Next.js)", () => {
});

it("passes sdkUrl to the Script component", () => {
render(<UnicornScene projectId="test-id" sdkUrl="https://custom-cdn.example.com/sdk.js" />);
render(
<UnicornScene
projectId="test-id"
sdkUrl="https://custom-cdn.example.com/sdk.js"
/>,
);
const script = screen.getByTestId("next-script");
expect(script).toHaveAttribute("src", "https://custom-cdn.example.com/sdk.js");
expect(script).toHaveAttribute(
"src",
"https://custom-cdn.example.com/sdk.js",
);
});

it("uses lazyOnload strategy when lazyLoad is true", () => {
Expand Down
37 changes: 37 additions & 0 deletions src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1 +1,38 @@
import "@testing-library/jest-dom/vitest";
import { vi } from "vitest";

// jsdom does not provide ResizeObserver — provide a minimal mock that tracks
// observed elements and exposes a helper to simulate resize entries.
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;

class MockResizeObserver {
static instances: MockResizeObserver[] = [];

callback: ResizeCallback;
elements: Set<Element> = new Set();

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

observe(target: Element) {
this.elements.add(target);
}
unobserve(target: Element) {
this.elements.delete(target);
}
disconnect = vi.fn(() => {
this.elements.clear();
});

/** Test helper: fire the callback with a minimal entry for `target`. */
simulateResize(target: Element) {
const entry = { target } as ResizeObserverEntry;
this.callback([entry]);
}
}

vi.stubGlobal("ResizeObserver", MockResizeObserver);

export { MockResizeObserver };
191 changes: 149 additions & 42 deletions src/__tests__/useUnicornScene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useUnicornScene } from "../shared/hooks";
import type { UnicornStudioScene } from "../shared/types";
import { MockResizeObserver } from "./setup";

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -62,6 +63,7 @@ describe("useUnicornScene", () => {
vi.restoreAllMocks();
delete (window as Record<string, unknown>).UnicornStudio;
containerEl.remove();
MockResizeObserver.instances.length = 0;
});

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -103,9 +105,7 @@ describe("useUnicornScene", () => {
addSceneMock.mockResolvedValueOnce(scene);
const onLoad = vi.fn();

renderHook(() =>
useUnicornScene({ ...defaultProps(elementRef), onLoad }),
);
renderHook(() => useUnicornScene({ ...defaultProps(elementRef), onLoad }));

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

Expand Down Expand Up @@ -135,10 +135,9 @@ describe("useUnicornScene", () => {
const scene = createMockScene();
addSceneMock.mockResolvedValue(scene);

const { rerender } = renderHook(
(props) => useUnicornScene(props),
{ initialProps: { ...defaultProps(elementRef), onLoad: () => {} } },
);
const { rerender } = renderHook((props) => useUnicornScene(props), {
initialProps: { ...defaultProps(elementRef), onLoad: () => {} },
});

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

const { rerender } = renderHook(
(props) => useUnicornScene(props),
{ initialProps: defaultProps(elementRef) },
);
const { rerender } = renderHook((props) => useUnicornScene(props), {
initialProps: defaultProps(elementRef),
});

await act(async () => {});
expect(addSceneMock).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -206,14 +204,16 @@ describe("useUnicornScene", () => {
let resolveFirst!: (s: UnicornStudioScene) => void;
addSceneMock
.mockImplementationOnce(
() => new Promise<UnicornStudioScene>((r) => { resolveFirst = r; }),
() =>
new Promise<UnicornStudioScene>((r) => {
resolveFirst = r;
}),
)
.mockResolvedValueOnce(createMockScene());

const { rerender } = renderHook(
(props) => useUnicornScene(props),
{ initialProps: defaultProps(elementRef) },
);
const { rerender } = renderHook((props) => useUnicornScene(props), {
initialProps: defaultProps(elementRef),
});

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

// Resolve the first (its ignore flag is set, so the scene gets destroyed)
const lateScene = createMockScene();
await act(async () => { resolveFirst(lateScene); });
await act(async () => {
resolveFirst(lateScene);
});
expect(lateScene.destroy).toHaveBeenCalled();
});

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

const { rerender } = renderHook(
(props) => useUnicornScene(props),
{
initialProps: {
...defaultProps(elementRef),
sceneRef: oldRef,
},
const { rerender } = renderHook((props) => useUnicornScene(props), {
initialProps: {
...defaultProps(elementRef),
sceneRef: oldRef,
},
);
});

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

const { rerender } = renderHook(
(props) => useUnicornScene(props),
{
initialProps: {
...defaultProps(elementRef),
sceneRef: oldRefFn,
},
const { rerender } = renderHook((props) => useUnicornScene(props), {
initialProps: {
...defaultProps(elementRef),
sceneRef: oldRefFn,
},
);
});

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

const { rerender } = renderHook(
(props) => useUnicornScene(props),
{ initialProps: { ...defaultProps(elementRef), paused: false } },
);
const { rerender } = renderHook((props) => useUnicornScene(props), {
initialProps: { ...defaultProps(elementRef), paused: false },
});

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

Expand Down Expand Up @@ -407,7 +402,10 @@ describe("useUnicornScene", () => {
const scene = createMockScene();
let resolveAddScene!: (s: UnicornStudioScene) => void;
addSceneMock.mockImplementationOnce(
() => new Promise<UnicornStudioScene>((r) => { resolveAddScene = r; }),
() =>
new Promise<UnicornStudioScene>((r) => {
resolveAddScene = r;
}),
);

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

const props = defaultProps(elementRef);
const { rerender } = renderHook(
(p) => useUnicornScene(p),
{ initialProps: props },
);
const { rerender } = renderHook((p) => useUnicornScene(p), {
initialProps: props,
});

await act(async () => {});
expect(addSceneMock).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -581,4 +578,114 @@ describe("useUnicornScene", () => {
expect(onError).toHaveBeenCalled();
expect(result.current.error).toBeInstanceOf(Error);
});

// -----------------------------------------------------------------------
// ResizeObserver – container resize triggers scene.resize()
// -----------------------------------------------------------------------

it("calls scene.resize() when the container is resized", async () => {
const resizeFn = vi.fn();
const scene = createMockScene({ resize: resizeFn });
addSceneMock.mockResolvedValueOnce(scene);

renderHook(() => useUnicornScene(defaultProps(elementRef)));

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

// Find the observer watching our container
const observer = MockResizeObserver.instances.find((o) =>
o.elements.has(containerEl),
);
expect(observer).toBeDefined();

// Simulate a container resize
act(() => {
observer!.simulateResize(containerEl);
});

expect(resizeFn).toHaveBeenCalledTimes(1);
});

it("handles scene.resize being undefined gracefully", async () => {
const scene = createMockScene({ resize: undefined });
addSceneMock.mockResolvedValueOnce(scene);

renderHook(() => useUnicornScene(defaultProps(elementRef)));

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

const observer = MockResizeObserver.instances.find((o) =>
o.elements.has(containerEl),
);

// Should not throw when resize is undefined
expect(() => {
act(() => {
observer!.simulateResize(containerEl);
});
}).not.toThrow();
});

it("disconnects the ResizeObserver on unmount", async () => {
const scene = createMockScene({ resize: vi.fn() });
addSceneMock.mockResolvedValueOnce(scene);

const { unmount } = renderHook(() =>
useUnicornScene(defaultProps(elementRef)),
);

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

const observer = MockResizeObserver.instances.find((o) =>
o.elements.has(containerEl),
);
expect(observer).toBeDefined();

unmount();

expect(observer!.disconnect).toHaveBeenCalled();
});

it("degrades gracefully when ResizeObserver is unavailable", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
// @ts-expect-error -- simulating an environment without ResizeObserver
delete globalThis.ResizeObserver;

try {
addSceneMock.mockResolvedValueOnce(createMockScene({ resize: vi.fn() }));

const { unmount } = renderHook(() =>
useUnicornScene(defaultProps(elementRef)),
);

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

// No ResizeObserver should have been created
expect(MockResizeObserver.instances).toHaveLength(0);

// Should not throw on unmount either
expect(() => unmount()).not.toThrow();
} finally {
globalThis.ResizeObserver = originalResizeObserver;
}
});

it("does not create a ResizeObserver when elementRef is null", async () => {
const instancesBefore = MockResizeObserver.instances.length;

renderHook(() =>
useUnicornScene({
...defaultProps({ current: null }),
isScriptLoaded: true,
}),
);

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

const newObservers = MockResizeObserver.instances.slice(instancesBefore);
const observersWithElements = newObservers.filter(
(o) => o.elements.size > 0,
);
expect(observersWithElements).toHaveLength(0);
});
});
16 changes: 16 additions & 0 deletions src/shared/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,5 +414,21 @@ export function useUnicornScene({
}
}, [paused]);

// Observe container resize and call scene.resize() so the canvas adapts
useEffect(() => {
const el = elementRef.current;
if (!el || typeof ResizeObserver === "undefined") return;

const observer = new ResizeObserver(() => {
internalSceneRef.current?.resize?.();
});
Comment thread
diegopeixoto marked this conversation as resolved.

observer.observe(el);

return () => {
observer.disconnect();
};
}, [elementRef]);

return { error: initError };
}
Loading